Skip to content

0.3.0: declare once, call directly — the definition module - #4

Open
bweis wants to merge 4 commits into
mainfrom
feat/one-declaration
Open

0.3.0: declare once, call directly — the definition module#4
bweis wants to merge 4 commits into
mainfrom
feat/one-declaration

Conversation

@bweis

@bweis bweis commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

What

0.3.0: the definition module. Declare each capability once and call it directly inside the handler:

const Charge = defineActivity("charge", { payload: { orderId: Schema.String }, success: Schema.String });
const Approval = defineDeferred("order/approval", { success: Schema.String });

const OrderLive = OrderFlow.toLayer((payload) =>
  Effect.gen(function* () {
    const receipt = yield* Charge({ orderId: payload.orderId });
    const approver = yield* Approval.await;
    return `${receipt}:by:${approver}`;
  }),
);

Every primitive requires exactly one service, WorkflowOps — the seam an engine implements. workflowBundle provides the Temporal runtime; the new makeTestWorkflowOps (testing module) provides an in-memory one, so the same handler function runs on real Temporal or in a plain unit test with no engine, no sandbox, no server. Handlers import nothing engine-shaped — which is the answer to the "TypedActivity feels like an engine leak" feedback.

Also in the module: version(site, names) (patch-marker logic branches) and evolved(current, legacy, migrate) (newest-first schema evolution with pure migrations). Client-side driving addresses the declaration's underlying primitive: U.update, M.mailbox, C.cell, D.deferred.

How it works

Declarations wrap the existing primitives (TypedActivity, DurableMailbox, DurableUpdate, StateCell, upstream DurableDeferred) and type each operation against the WorkflowOps context service. The seam (WorkflowOpsRuntime) is one operation per primitive kind over unknown; each define* narrows it exactly once, against the schemas the same declaration carries. workflowBundle provides the Temporal implementation to hosted layers during registration; the per-run wrapper supplies the sandbox services the ops dispatch into. Wire identity stays the explicit name string, so refactors never change the wire.

Migration

The whole repo authors with define*: 16 test fixtures, both examples, all docs (new "Declaring capabilities" guide page), README, EXAMPLES.md. The versioning-chain fixtures deliberately stay on raw Activity.make — they prove replay compatibility with byte-identical commands across code generations, and the full replay drill passes unchanged, which doubles as proof the new plumbing does not alter recorded histories. Low-level per-primitive calls (callActivity, takeMailbox, ...) remain exported from engine-sandbox as the machinery underneath, undocumented as authoring surface.

Audit (second commit)

A dedicated bug/type-honesty audit ran before this PR; its findings are the second commit:

  • eraseR replaces the as never casts on the Temporal ops runtime — only the R channel is erased; success/error shapes stay compile-checked against the seam. UpdateRequest unified to one shape. temporalWorkflowOps made module-private (outside the per-run wrapper it would die at call time).
  • WorkflowOpsRuntime.version is generic over the names tuple: three casts deleted, both engines implement it verbatim.
  • The memory runtime round-trips every channel through the declaration's wire codec (activity payload/success/error, mailbox and update payloads, update responses, state values, deferred completions) — schema-invalid values defect in unit tests exactly as they would on Temporal. Responding twice to one update dies, mirroring the engine.
  • The versioning-on-main-fiber lint rule now covers definition-only handler modules via their version import (alias-aware); the old import gate made the new detection dead for exactly those files.

Validation

  • typecheck, lint, build, docs build (dead links checked): clean
  • full suite: 24 files / 63 tests green, incl. the new definition.test.ts (same handler on memory + real Temporal, typed update refusal, typed activity failure) and schema-evolution.test.ts
  • both examples run end-to-end on the new surface
  • versioning-chain replay drill green (histories unchanged)

bweis added 4 commits August 27, 2026 12:17
Question: can one defineWorkflow declaration (workflow + activities +
messages + state) make types flow to handler, worker, and client, with the
handler engine-agnostic — answering the TypedActivity/DurableDeferred leak?

Verdict: YES. The same handler function object runs on (a) a plain
in-memory Effect runtime with no engine anywhere and (b) real Temporal via
the existing engine, driven identically; payload/success/typed-error
inference is pinned end to end; implement() is completeness-checked.

Design notes: the single seam is OpsRuntime (six untyped operations the
typed ops toolkit dispatches through); primitives are materialized from the
declaration (names namespaced by tag); handler R = never. Prototype casts
live in makeOps and the temporal runtime — a production version would type
the seam the way SandboxHandler was typed.

Throwaway. Not for merge; the validated decision informs the 0.3.0 API
conversation and the upstream schema'd-activity proposal.
…RDICT: yes)

Because every boundary is schema-encoded JSON decoded deterministically on
replay, data versioning reduces to: the current schema must decode the wire
old code wrote. evolved(current, legacy, migrate) is the declaration-level
answer — newest-first union, pure forward migrations, one newest Type for
handlers, legacy shapes never re-encoded. Proven through the real wire
codec including the V1-history-decodes-under-V2-code case.
Declarations are now callable inside handlers: yield* Charge({ orderId }),
Approval.await, Status.set(...). Every primitive requires only the
WorkflowOps service, the one seam an engine implements, so handlers import
nothing engine-shaped. workflowBundle provides the Temporal runtime;
makeTestWorkflowOps (testing) provides an in-memory one, so the same
handler runs on real Temporal or in a plain unit test.

Also in the definition module: version(site, names) for patch-marker logic
branches and evolved(current, legacy, migrate) for newest-first schema
evolution with pure migrations.

Fixtures, examples, and docs authored with define* throughout. Client-side
driving addresses the declaration's underlying primitive (.update,
.mailbox, .cell, .deferred). Lint: the fork/race versioning rule now also
catches the bare version() call.
From the pre-PR bug/type-honesty audit:

- engine-sandbox: eraseR replaces the as-never casts on the WorkflowOps
  runtime, so only the R channel is erased and success/error shapes stay
  compile-checked against the seam. UpdateRequest is now an alias of the
  definition module's (one shape, no drift). temporalWorkflowOps is
  module-private: outside the per-run wrapper its erased services are
  missing and every op would die at call time.
- definition: WorkflowOpsRuntime.version is generic over the names tuple,
  deleting three casts across the seam and both engines. Dead deferred
  cast removed. Documented that declaration schemas must be context-free.
- testing: the memory runtime now round-trips EVERY channel through the
  declaration's wire codec (activity payload/success/error, mailbox and
  update payloads, update responses, state values, deferred completions),
  so schema-invalid values defect in unit tests exactly as they would on
  Temporal. Responding twice to one update dies, mirroring the engine.
- lint: the versioning-on-main-fiber rule now recognizes definition-only
  handler modules (which import nothing engine-shaped) via their version
  import, alias-aware; the old gate made the bare-version detection dead
  for exactly the files it was added for. Covered by a new lint fixture.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant